Modernize for current Linux/Python (BlueZ 5.83, Python 3.12)#39
Open
exkuretrol wants to merge 24 commits intoPoohl:amiibo_editsfrom
Open
Modernize for current Linux/Python (BlueZ 5.83, Python 3.12)#39exkuretrol wants to merge 24 commits intoPoohl:amiibo_editsfrom
exkuretrol wants to merge 24 commits intoPoohl:amiibo_editsfrom
Conversation
hciconfig and hcitool are deprecated on modern distros (e.g. recent Debian/Ubuntu, Oracle Linux 10) where bluez no longer ships them. The project now prefers btmgmt (bluez-tools) for adapter reset, class setting, and BD address changes, falling back to the legacy tools when present, and to raw HCI sockets via socket.AF_BLUETOOTH as a last resort. Also drop deprecated Python idioms: - pkg_resources -> importlib.resources for the bundled SDP record - asyncio.get_event_loop() -> asyncio.get_running_loop() inside coros, asyncio.run() at entry points; remove the mutable-default loop=asyncio.get_event_loop() arg evaluated at import time - bump python_requires to >=3.9, add crc8 to install_requires Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two upstream bugs surfaced when running under Python 3.12:
1) `_writer` had a bare `except: break` that silently swallowed every
exception, including asyncio.CancelledError (which derives from
BaseException since 3.8). The result on 3.12: the writer task exits
immediately after starting, the input-report stream stops, and the
Switch refuses to accept the controller as a player. Replace with
targeted handlers: re-raise CancelledError, log NotConnectedError at
info, log every other exception with a traceback before breaking.
2) `connection_lost` called `set_exception()` on
`self._controller_state_sender`, which was created via
`asyncio.ensure_future(...)` and is therefore a Task. Python 3.10+
disallows `Task.set_exception()` ("Task does not support
set_exception operation"). Switch the sender to a plain Future
created via `loop.create_future()`, driven by a helper waiter task
that mirrors the sig_is_send event onto the future. The Future
still supports set_exception(), so disconnect handling works again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
asyncio.Semaphore stopped accepting/storing a `loop` parameter in Python 3.10, and on 3.12 `self._loop` is None on the subclass. The writer task crashed with `AttributeError: 'NoneType' object has no attribute 'create_future'` on every input report, so the input-report stream never started and the Switch wouldn't accept the controller. Resolve the loop the supported way — `asyncio.get_running_loop()` from inside the coroutine — when constructing the per-request Future. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the CLI exits, transport.close() cancels the writer task. The writer correctly propagates CancelledError, but the task's done callback (create_error_check_callback) re-raised it, which asyncio's default handler then logged twice as "Exception in callback". All the other start_asyncio_thread() callers in transport.py already pass ignore=asyncio.CancelledError. The writer thread had been missing this. Match the others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the user types `exit`, transport.close() calls connection_lost(None).
Per asyncio convention, exc=None means clean shutdown — but the protocol
was logging it as ERROR ("Connection lost."), which made every clean exit
look like a failure.
- protocol.connection_lost(): log INFO when exc is None, ERROR when an
exception is provided.
- transport.write(): when an OSError/ConnectionResetError forces us into
connection_lost(), pass the exception so the log path knows it's a real
loss and not a clean close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bluez already tracks per-device bond state in /var/lib/bluetooth/<adapter>/ <addr>/info, and the file's mtime gets bumped whenever the bond is touched (connect / disconnect / link-key update). That makes it a reasonable proxy for "most recently connected" without joycontrol having to track its own connection log. The script lists paired devices for the default adapter, sorted by mtime descending, with the device name and bond timestamp. Useful for finding the address to pass to `run_controller_cli.py -r <addr>`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The writer loop targets a 1/60 s cadence in input-report modes 0x30/0x31. Routine sub-100 ms overruns happen on every connection — Linux scheduler jitter and L2CAP flow-control waits where the writer correctly blocks on _write_lock but active_time still accounts for the wait. Logging every one of those at WARNING level was just noise, and drowned out actual stalls worth investigating. Only warn for overruns greater than 100 ms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
server.py iterated `paths.items()` for the interactive picker, but
`HidDevice.get_paired_switches()` returns a list, not a dict — so
running `run_controller_cli.py -r auto` against a host with more
than one paired/cached Switch crashed with AttributeError.
Use `enumerate(paths, start=1)` to match the 1-based prompt that
follows ("number 1 - N [1]:").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When `-r auto` finds multiple paired Switches, the picker now shows the last bond mtime alongside each DBus path so you can tell which Switch is the most recent. It also adds a "0: abort" option (or 'q'/'Q') so you can back out of the prompt without picking blindly. Bond mtime is read from /var/lib/bluetooth/<adapter>/<addr>/info — the same file timestamp used by scripts/list_paired_switches.sh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old aioconsole.ainput-based prompt had three usability problems: - log lines printed mid-input garbled the `cmd >>` prompt and any text the user had typed - no tab completion - no command history / arrow-key recall across runs Replace it with prompt_toolkit's PromptSession: - patch_stdout(raw=True) buffers writes during the prompt and replays them on the line above, so logs no longer collide with input - WordCompleter built from registered cmd_* methods, dynamic commands, button names (for ControllerCLI), and stick keywords gives Tab completion for the things you actually type - FileHistory at $JOYCONTROL_STATE_DIR (or XDG_STATE_HOME or ~/.local/state)/joycontrol/cli_history persists Up/Down history between runs logging_default now writes to sys.stdout instead of the default stderr, so patch_stdout intercepts log output. Ctrl-D / Ctrl-C at the prompt exit cleanly via the existing return path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in run_controller_cli.py's mash/test_buttons commands: 1) They still used aioconsole.ainput, which competed for stdin against prompt_toolkit's terminal-mode handling left over from the parent CLI session. Result: pressing Enter never resolved the input future and the loop ran forever until Ctrl-C. 2) Even if Enter had been received, the loop awaited `asyncio.sleep(interval)` between button pushes, so a stop only took effect after the next full interval elapsed — visibly broken for `mash x 0.5` and worse for longer intervals. Add joycontrol.command_line_interface.wait_for_enter() that uses a fresh PromptSession (single source of truth for stdin in this process) and call it from both spots. Race the inter-button sleep against the stop signal via asyncio.wait_for(shield(user_input), timeout=...) so Enter ends the loop immediately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most invocations of run_controller_cli.py needed -r auto to reconnect to an already-paired Switch. Make that the default so a bare invocation "just works": - argparse default for --reconnect_bt_addr is now 'auto'. - create_hid_server() normalizes 'auto' to None when no Switch is paired, falling through to the initial-pairing flow instead of fatal-exiting. - '' or 'none' (case-insensitive) are accepted as an explicit opt-out for users who really want to force initial pairing even with bonds present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the positional `controller` arg optional with a PRO_CONTROLLER default. The bare invocation `run_controller_cli.py` now sets up a Pro Controller, which combined with -r defaulting to 'auto' lets you reconnect to your last Switch with zero arguments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the manually-typed "JOYCON_R, JOYCON_L or PRO_CONTROLLER (default: PRO_CONTROLLER)" help string with `choices=[c.name for c in Controller]`, which makes argparse: - show the valid options in the auto-generated usage line - reject invalid values with a clear "invalid choice" error before Controller.from_arg gets called - stay in sync if a new Controller variant is ever added argparse appends the default to the help automatically when "%(default)s" is used, but here `choices=` plus the existing default already produce the right rendering, so the help text can just describe purpose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sort `paths` from get_paired_switches() by the bond file's mtime descending before showing the picker. Effects: - Interactive picker: option 1 is always the most-recently-bonded Switch — what you almost always want when reconnecting. - Non-interactive `-r auto` with multiple paired Switches: previously picked an arbitrary entry (DBus iteration order); now picks the most recent one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the auto-resolution logic into _resolve_auto() and run it as part of reconnect_bt_addr normalization, before the if/else split. This lets the picker fall through to initial pairing by simply returning None — no special-case plumbing needed in the caller. User-visible changes: - The picker now shows even when only one Switch is paired (so you can pair a new one without first unpairing what you have). - New 'n' (or 'new') option pairs a fresh Switch via the existing initial-pairing flow. - Existing '0' / 'q' (abort) and Enter (most-recent default) still work. - Numeric choices outside the valid range now abort cleanly with a message instead of crashing with IndexError. Sample prompt: found the following paired switches, please choose one: 1: /org/bluez/hci0/dev_AA_.. (last bond: 2026-05-01 16:36:48) 2: /org/bluez/hci0/dev_BB_.. (last bond: 2026-05-01 14:31:04) n: pair a new Switch 0: abort number 1 - 2, n to pair new, 0 to abort [1]: Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'Warning: a switch was found paired, do you want to unpair it?' prompt fired on every initial-pairing attempt for every existing bond, forcing the user through a y/n cycle just to pair an additional Switch. Now that the auto picker has explicit 'pair new' / 'reconnect' / 'abort' options, the user has already made the connect-vs-pair choice upstream. The unpair prompt offered no useful new information — it just disturbed unrelated bonds. Drop the prompt and the matching non-interactive 'switches are paired' warning. The SDP-record-count warning is preserved because it's still informational about Switch compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the picker into a reusable read-eval loop so the user can: - 1..N: reconnect to that paired Switch - Enter: most recent (default) - n / new: fall through to initial pairing - u / unpair: pick a Switch to forget, with y/N confirm; menu re-prints - 0 / q: abort Bad/out-of-range input no longer aborts the script — the menu just reprints with a hint, since the user is now in an interactive loop. The unpair path uses HidDevice.unpair_path (already implemented) which calls org.bluez.Adapter1.RemoveDevice — same effect as `bluetoothctl remove <addr>` — and reflows the menu on success. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installation: - Add Fedora/RHEL/Oracle Linux dnf line alongside the apt line. - Pivot to `pip install .` (or local venv) since setup.py now lists the full runtime dep set (hid, aioconsole, crc8, prompt-toolkit, dbus-python). - One-line verification command for installed deps. Bluetooth service setup: - Replace the "edit /lib/systemd/system/bluetooth.service" instructions with a systemd drop-in override at /etc/systemd/system/bluetooth. service.d/override.conf, which survives package upgrades. - Document the bluetoothd path difference between Debian-likes (/usr/lib/bluetooth/bluetoothd) and RHEL-likes (/usr/libexec/bluetooth/bluetoothd) and how to check. - Spell out which services break (input/sap/avrcp) so users know what they're trading away. CLI section: - Refresh argparse usage block (controller arg now optional, -r defaults to "auto", `-r ""`/`none` opt-out documented). - Document the new picker (1..N / n / u / 0) with a cheat-sheet table. - Mention scripts/list_paired_switches.sh. - Document tab completion, history, patch_stdout behavior in the prompt. Issues section: refresh the "reconnect spins" entry to point at the new in-picker unpair option instead of dropping out to bluetoothctl. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On Oracle Linux / RHEL / AlmaLinux / Rocky, bluez-libs-devel ships in the CodeReady Builder (CRB) repo, which isn't enabled by default. Add the per-distro enable command so first-time installers don't get a "package not found" error from the dnf line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aioconsole.ainput was the original stdin reader; the prompt_toolkit refactor replaced every call site (CLI prompt, mash, test_buttons, the various 'press <enter> to stop' blockers). The package isn't imported anywhere now — the only remaining references are dead install_requires + verification command lines. Keep the historical mention in command_line_interface.wait_for_enter's docstring since it explains why we route stdin through prompt_toolkit sessions exclusively (avoiding the two-readers-on-stdin race that broke mash's stop-on-enter under terminal raw mode). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…m-site-packages Two real issues hit while dogfooding the README install on a clean Oracle Linux 10 host: 1. `pip install .` failed with `metadata-generation-failed` for dbus-python. Listing it in install_requires triggers a build from source (no compiler in venv → fails). Drop it from install_requires with an in-tree comment explaining why; treat it as a system package only. 2. `bluez-tools` doesn't exist on RHEL family — `btmgmt` ships in the main `bluez` package there. Adjust the dnf line and document the distro split. README now uses `python3 -m venv --system-site-packages .venv` so the distro python3-dbus stays visible inside the project venv. The verify command runs against the venv interpreter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks through the full setup from a clean clone, written for readers
who've never set up a Python venv or BlueZ before. Each step explains
*why* it's needed, not just what to type.
Installation:
1. git clone (start from zero)
2. distro-package install (apt vs dnf, with CRB enable for RHEL)
3. python3 -m venv --system-site-packages .venv (with explanation
of why the flag matters — system dbus visibility)
4. sudo .venv/bin/pip install . (with a note on why sudo)
5. import-test verification with the failure-mode hint
Bluetooth setup also reformatted as numbered steps, with a small table
mapping distro family to bluetoothd binary path so users can fill
their own value into the drop-in override correctly. Adds an explicit
"how to revert" line and an adapter-existence sanity check.
No behavior change — README only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`sudo python3 run_controller_cli.py` would run the script under the system Python, which doesn't see the venv's prompt-toolkit / hid / crc8 / etc., producing "ModuleNotFoundError: No module named 'prompt_toolkit'" right at startup. Using `.venv/bin/python` invokes the venv interpreter directly without needing source-activate. Add an upfront `cd /path/to/joycontrol` and explain the .venv/bin/ prefix for first-time readers. Also call out that list_paired_switches.sh is run from the project directory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings joycontrol back to a working state on modern Linux distributions and Python releases. Verified
end-to-end on Oracle Linux 10.1 (BlueZ 5.83 / Python 3.12), should still work fine on Raspbian / Debian /
Ubuntu.
The branch covers three buckets of work: (1) replace deprecated/removed BlueZ tooling, (2) fix Python
3.10–3.12 asyncio API breakages that surfaced during runtime testing, (3) overhaul the interactive CLI so
it's usable for actual play sessions.
No behavioral change for the protocol layer itself — Switch handshake, NFC/amiibo, controller emulation are
unchanged. All fixes are below the protocol or in the CLI/host layer.
Modernization
hciconfig/hcitool→btmgmt. The legacy bluez CLI tools are no longer shipped on recentDebian/Ubuntu/RHEL/Oracle Linux.
joycontrol/device.pynow prefersbtmgmt(bluez-tools) for adapterreset, device-class setting, and BD-address changes; falls back to
hciconfig/hcitool/bdaddronlywhen present, and to a raw HCI socket (
socket.AF_BLUETOOTH,BTPROTO_HCI) for vendor-specific commandsas a last resort.
scripts/change_btaddr.shand the README follow the same precedence.pkg_resources→importlib.resourcesfor the bundled SDP record XML.asyncio.get_event_loop()at module/import time replaced withget_running_loop()inside coroutines and
asyncio.run()at entry points. Theloop=asyncio.get_event_loop()mutable defaultin
AsyncHID.__init__is gone.setup.pybumped to 0.16,python_requires>=3.9, drops the unusedaioconsole, addscrc8andprompt-toolkittoinstall_requires.Python 3.10/3.12 runtime fixes
These were not theoretical — each one reproduced in real pairing sessions on the test host before the fix.
joycontrol/protocol.pyconnection_lostcalledTask.set_exception()on a Task created viaasyncio.ensure_future. Disallowed since 3.10 ("Task does not support set_exception operation"). Switched toloop.create_future()driven by a helper waiter task.joycontrol/protocol.py_writerhad a bareexcept: breakthat silently swallowedasyncio.CancelledError. On 3.12 (whereCancelledErrorderives fromBaseException) the writer died after handshake → input-report stream stopped → Switch dropped the controller. Replaced with targeted handlers + traceback log. This was the actual root cause of "pair didn't succeed" against Python 3.12.joycontrol/my_semaphore.py_Requestusedloop.create_future()fromself._loop, butasyncio.Semaphoreno longer exposes_loopon 3.10+. Every transport write raisedAttributeError: 'NoneType' object has no attribute 'create_future', killing the writer. Now resolved viaasyncio.get_running_loop().joycontrol/protocol.pystart_asyncio_thread(self._writer())withoutignore=asyncio.CancelledError, so clean shutdown logged "Exception in callback" tracebacks. Matches the convention used elsewhere intransport.py.joycontrol/protocol.py/joycontrol/transport.pyconnection_lostalways logged at ERROR — even on a user-initiatedexit. Now logs INFO whenexc is None(clean), ERROR when an exception is provided; transport's write-error path passes the exception.joycontrol/protocol.pyjoycontrol/server.py-r autopicker calledpaths.items()on a list, crashing withAttributeErrorwhenever multiple Switches were paired.CLI overhaul
prompt_toolkit-based prompt replacesaioconsole.ainput:~/.local/state/joycontrol/cli_history(overridable via$JOYCONTROL_STATE_DIRor$XDG_STATE_HOME).patch_stdout; thecmd >>line stays clean while logs render onlines above. Logging is reconfigured to write to stdout for this to take effect.
mash/test_buttonsstop-on-Enter fixed. They had been stuck onaioconsole.ainputafter theprompt switched, racing prompt_toolkit on stdin. Now use a single
wait_for_enterhelper, and theinter-press sleep races against the stop signal so Enter takes effect immediately.
Ctrl-C/Ctrl-Dat the prompt now exit cleanly.Picker UX (when
-r autois in effect)controllerarg is now optional — defaults toPRO_CONTROLLER. Usesargparsechoices=so--helpalways shows the valid set.-rdefaults to'auto', falling through to initial pairing if no Switch is bonded.-r ""or-r noneopts out for users who want a forced pair.sudo run_controller_cli.pynow does what you almost always want: emulate Pro Controller, reconnectto the most-recently-bonded Switch.
nfalls through to initial pairing without disturbing existing bonds (the old "do you want to unpairevery paired Switch?" gauntlet is gone).
udoes anorg.bluez.Adapter1.RemoveDevicewith ay/Nconfirm; menu reflows.scripts/list_paired_switches.sh— shell helper that lists paired devices for the default adaptersorted by
/var/lib/bluetooth/<adapter>/<dev>/infomtime. Useful for finding the address to pass to-r.README / setup
Rewrote Installation and Bluetooth service setup:
dnfline alongside the existingaptline, with a CodeReady Builderenable command for
bluez-libs-devel.pip install .(or a project-local venv)./lib/systemd/system/bluetooth.service" with a systemd drop-in override at/etc/systemd/system/bluetooth.service.d/override.conf(won't be clobbered by package upgrades).bluetoothdpath difference between Debian-likes (/usr/lib/bluetooth/bluetoothd) andRHEL-likes (
/usr/libexec/bluetooth/bluetoothd).Testing
Verified on:
btmgmtonly — nohcitool/hciconfig/bdaddravailable)-r auto(single + multiple-Switch picker) ✅exit,Ctrl-C,Ctrl-D) without tracebacks ✅mashwith decimal interval — Enter stops within one interval ✅Compile-tested all touched Python files with
python3 -m py_compile.Migration notes
-r autosee the new picker (withn/uoptions) instead of the oldbehavior. If you don't want the picker, pass an explicit
-r <bdaddr>.bluez-tools: install it (apt install bluez-tools/dnf install bluez-tools) — the project warns and falls back tohciconfig/hcitoolif those exist, but therecommended path is
btmgmt.aioconsolePython dependency is removed; it's safe topip uninstall aioconsole.prompt-toolkitis a new runtime dep.Test plan
nin the picker, with another Switch already bonded-r <addr>u/unpair from the picker, then immediate re-pairmash a 0.5and confirm Enter stops it promptlyexitshowsConnection closed.at INFO, no traceback